package reporter

import (
	"os"
	"path"
	"slices"
	"testing"

	malysisv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/malysis/v1"
	cdx "github.com/CycloneDX/cyclonedx-go"
	"github.com/safedep/dry/utils"
	"github.com/stretchr/testify/assert"

	"github.com/safedep/vet/gen/insightapi"
	"github.com/safedep/vet/pkg/models"
)

var cdxTestToolMetaData = ToolMetadata{
	Name:                 "test-tool",
	Version:              "1.0.0",
	Purl:                 "pkg:generic/test-tool@1.0.0",
	InformationURI:       "https://example.com/tool",
	VendorName:           "Test Vendor",
	VendorInformationURI: "https://example.com",
}

func decodeCdxSbom(path string) (*cdx.BOM, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	bom := new(cdx.BOM)
	decoder := cdx.NewBOMDecoder(file, cdx.BOMFileFormatJSON)
	if err = decoder.Decode(bom); err != nil {
		return nil, err
	}

	return bom, nil
}

func TestNewCycloneDxReporter(t *testing.T) {
	cdxPath := path.Join(t.TempDir(), "cdx.json")
	cdxAppName := "test-project"

	reporter, err := NewCycloneDXReporter(CycloneDXReporterConfig{
		Tool:                     cdxTestToolMetaData,
		Path:                     cdxPath,
		ApplicationComponentName: cdxAppName,
	})
	assert.NoError(t, err)
	assert.NotNil(t, reporter)

	err = reporter.Finish()
	assert.NoError(t, err)
	assert.FileExists(t, cdxPath)

	// Verify generated CycloneDX SBOM file
	generatedBom, err := decodeCdxSbom(cdxPath)
	assert.NoError(t, err)
	assert.NotNil(t, generatedBom)

	// Verify root application component
	assert.NotNil(t, generatedBom.Metadata.Component)
	assert.Equal(t, "root-application", generatedBom.Metadata.Component.BOMRef)
	assert.Equal(t, cdx.ComponentTypeApplication, generatedBom.Metadata.Component.Type)
	assert.Equal(t, cdxAppName, generatedBom.Metadata.Component.Name)

	// Verify tool metadata component
	assert.Len(t, utils.SafelyGetValue(utils.SafelyGetValue(generatedBom.Metadata.Tools).Components), 1)
	toolComponent := utils.SafelyGetValue(utils.SafelyGetValue(generatedBom.Metadata.Tools).Components)[0]
	assert.Equal(t, cdx.ComponentTypeApplication, toolComponent.Type)
	assert.NotNil(t, toolComponent.Manufacturer)
	assert.Equal(t, toolComponent.Manufacturer.Name, cdxTestToolMetaData.VendorName)
	assert.ElementsMatch(t, utils.SafelyGetValue(toolComponent.Manufacturer.URL), []string{cdxTestToolMetaData.VendorInformationURI})
	assert.Equal(t, cdxTestToolMetaData.VendorName, toolComponent.Group)
	assert.Equal(t, cdxTestToolMetaData.Name, toolComponent.Name)
	assert.Equal(t, cdxTestToolMetaData.Version, toolComponent.Version)
	assert.Equal(t, cdxTestToolMetaData.Purl, toolComponent.PackageURL)
	assert.Equal(t, cdxTestToolMetaData.Purl, toolComponent.BOMRef)
}

func TestCycloneDxReporterSerialNumber(t *testing.T) {
	cases := []struct {
		name                      string
		providedSerialNumber      string
		invalidSerialNumberErr    bool
		autoGeneratedSerialNumber bool
		expectedSerialNumber      string
	}{
		{
			"valid custom serial number",
			"urn:uuid:557b64cd-537b-4bc3-a287-43b755654297",
			false,
			false,
			"urn:uuid:557b64cd-537b-4bc3-a287-43b755654297",
		},
		{
			"invalid custom serial number",
			"invalid-serial",
			true,
			false,
			"",
		},
		{
			"auto generated serial number",
			"",
			false,
			true,
			"",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			cdxPath := path.Join(t.TempDir(), tc.name+".json")
			reporter, err := NewCycloneDXReporter(CycloneDXReporterConfig{
				Tool:                     cdxTestToolMetaData,
				Path:                     cdxPath,
				SerialNumber:             tc.providedSerialNumber,
				ApplicationComponentName: "test-project",
			})

			if tc.invalidSerialNumberErr {
				assert.Error(t, err)
				assert.Nil(t, reporter)
				assert.Contains(t, err.Error(), "does not match RFC 4122 UUID format")
			} else {
				assert.NoError(t, err)
				assert.NotNil(t, reporter)

				err = reporter.Finish()
				assert.NoError(t, err)
				assert.FileExists(t, cdxPath)

				generatedBom, err := decodeCdxSbom(cdxPath)
				assert.NoError(t, err)
				assert.NotNil(t, generatedBom)

				if tc.autoGeneratedSerialNumber {
					assert.NotEmpty(t, generatedBom.SerialNumber)
					assert.Regexp(t, cdxUUIDRegexp, generatedBom.SerialNumber)
				} else {
					assert.Equal(t, tc.expectedSerialNumber, generatedBom.SerialNumber)
				}
			}
		})
	}
}

func TestCycloneDxReporterManifestWithDeps(t *testing.T) {
	cdxPath := path.Join(t.TempDir(), "cdx.json")
	reporter, err := NewCycloneDXReporter(CycloneDXReporterConfig{
		Tool:                     cdxTestToolMetaData,
		Path:                     cdxPath,
		ApplicationComponentName: "test-project",
	})
	assert.NoError(t, err)
	assert.NotNil(t, reporter)

	manifest := &models.PackageManifest{
		Path:      "test/package-lock.json",
		Ecosystem: models.EcosystemNpm,
		Source: models.PackageManifestSource{
			Type:        models.ManifestSourceLocal,
			Path:        "test/package-lock.json",
			DisplayPath: "test/package-lock.json",
		},
	}

	var (
		pkgA = &models.Package{
			Manifest:       manifest,
			PackageDetails: models.NewPackageDetail("npm", "a", "1.0.0"),
		}
		pkgB = &models.Package{
			Manifest:       manifest,
			PackageDetails: models.NewPackageDetail("npm", "b", "1.0.0"),
		}
		pkgC = &models.Package{
			Manifest:       manifest,
			PackageDetails: models.NewPackageDetail("npm", "c", "1.0.0"),
		}
		pkgD = &models.Package{
			Manifest:       manifest,
			PackageDetails: models.NewPackageDetail("npm", "d", "1.0.0"),
		}
	)

	manifest.Packages = []*models.Package{pkgA, pkgB, pkgC, pkgD}

	manifest.DependencyGraph = models.NewDependencyGraph[*models.Package]()
	manifest.DependencyGraph.SetPresent(true)
	manifest.DependencyGraph.AddRootNode(pkgA)
	manifest.DependencyGraph.AddRootNode(pkgB)
	manifest.DependencyGraph.AddNode(pkgC)
	manifest.DependencyGraph.AddNode(pkgD)
	manifest.DependencyGraph.AddDependency(pkgA, pkgC)
	manifest.DependencyGraph.AddDependency(pkgA, pkgD)
	manifest.DependencyGraph.AddDependency(pkgB, pkgD)

	reporter.AddManifest(manifest)
	err = reporter.Finish()
	assert.NoError(t, err)

	generatedBom, err := decodeCdxSbom(cdxPath)
	assert.NoError(t, err)
	assert.NotNil(t, generatedBom)

	assert.Len(t, utils.SafelyGetValue(generatedBom.Metadata.Component.Components), 1)
	manifestComponent := utils.SafelyGetValue(generatedBom.Metadata.Component.Components)[0]
	assert.Equal(t, cdx.ComponentTypeApplication, manifestComponent.Type)
	assert.Equal(t, string(models.EcosystemNpm), manifestComponent.Group)
	assert.Equal(t, "test/package-lock.json", manifestComponent.BOMRef)
	assert.Equal(t, "test/package-lock.json", manifestComponent.Name)

	components := utils.SafelyGetValue(generatedBom.Components)
	assert.NotNil(t, components)
	assert.Len(t, components, 4)

	// Sort components by name for consistent ordering
	// This is important because neither CycloneDX does not guarantee the order of components nor does the go's map interface
	slices.SortFunc(components, func(a, b cdx.Component) int {
		if a.Name < b.Name {
			return -1
		} else if a.Name > b.Name {
			return 1
		}
		return 0
	})

	validatePackageComponent := func(component cdx.Component, pkg *models.Package) {
		pkgPurl := pkg.GetPackageUrl()
		assert.Equal(t, cdx.ComponentTypeLibrary, component.Type)
		assert.Equal(t, manifest.Ecosystem, component.Group)
		assert.Equal(t, pkg.GetName(), component.Name)
		assert.Equal(t, pkg.GetVersion(), component.Version)
		assert.Equal(t, pkgPurl, component.PackageURL)
		assert.Equal(t, pkgPurl, component.BOMRef)
	}
	assert.Len(t, components, 4)
	for i := 0; i < len(components); i++ {
		validatePackageComponent(components[i], manifest.Packages[i])
	}
}

func TestCycloneDxReporterLicenses(t *testing.T) {
	cdxPath := path.Join(t.TempDir(), "cdx.json")
	reporter, err := NewCycloneDXReporter(CycloneDXReporterConfig{
		Tool: cdxTestToolMetaData,
		Path: cdxPath,
	})
	assert.NoError(t, err)
	assert.NotNil(t, reporter)

	manifest := &models.PackageManifest{
		Path: "test/package.json",
		Source: models.PackageManifestSource{
			Type:        models.ManifestSourceLocal,
			Path:        "test/package.json",
			DisplayPath: "test/package.json",
		},
	}
	manifest.Packages = []*models.Package{
		{
			Manifest:       manifest,
			PackageDetails: models.NewPackageDetail("npm", "test-package", "1.0.0"),
			Insights: &insightapi.PackageVersionInsight{
				PackageCurrentVersion: utils.PtrTo("1.2.0"),
				Licenses:              &[]insightapi.License{"MIT"},
			},
		},
	}

	reporter.AddManifest(manifest)
	err = reporter.Finish()
	assert.NoError(t, err)

	generatedBom, err := decodeCdxSbom(cdxPath)
	assert.NoError(t, err)
	assert.NotNil(t, generatedBom)

	assert.Len(t, utils.SafelyGetValue(generatedBom.Components), 1)
	licenses := utils.SafelyGetValue(utils.SafelyGetValue(generatedBom.Components)[0].Licenses)
	assert.Len(t, licenses, 1)
	assert.Equal(t, "MIT", licenses[0].License.ID)
}

func TestCycloneDxReporterVuln(t *testing.T) {
	cdxPath := path.Join(t.TempDir(), "cdx.json")
	reporter, err := NewCycloneDXReporter(CycloneDXReporterConfig{
		Tool: cdxTestToolMetaData,
		Path: cdxPath,
	})
	assert.NoError(t, err)
	assert.NotNil(t, reporter)

	manifest := &models.PackageManifest{
		Path: "test/package.json",
		Source: models.PackageManifestSource{
			Type:        models.ManifestSourceLocal,
			Path:        "test/package.json",
			DisplayPath: "test/package.json",
		},
	}
	manifest.Packages = []*models.Package{
		{
			Manifest:       manifest,
			PackageDetails: models.NewPackageDetail("npm", "test-package", "1.0.0"),
			Insights: &insightapi.PackageVersionInsight{
				PackageCurrentVersion: utils.PtrTo("1.2.0"),
				Vulnerabilities: &[]insightapi.PackageVulnerability{
					{
						Id: utils.PtrTo("VULN-123"),
						Aliases: &[]string{
							"CVE-2023-1234",
							"CWE-79",
							"GHSA-abcd-efgh-ijkl",
						},
						Summary: utils.PtrTo("Test vulnerability"),
						Severities: &[]struct {
							Risk  *insightapi.PackageVulnerabilitySeveritiesRisk `json:"risk,omitempty"`
							Score *string                                        `json:"score,omitempty"`
							Type  *insightapi.PackageVulnerabilitySeveritiesType `json:"type,omitempty"`
						}{
							{
								Risk:  (*insightapi.PackageVulnerabilitySeveritiesRisk)(utils.PtrTo("HIGH")),
								Score: utils.PtrTo("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"),
								Type:  utils.PtrTo(insightapi.PackageVulnerabilitySeveritiesTypeCVSSV3),
							},
						},
					},
				},
			},
		},
	}

	reporter.AddManifest(manifest)
	err = reporter.Finish()
	assert.NoError(t, err)

	generatedBom, err := decodeCdxSbom(cdxPath)
	assert.NoError(t, err)
	assert.NotNil(t, generatedBom)

	vulns := utils.SafelyGetValue(generatedBom.Vulnerabilities)
	assert.NotNil(t, vulns)
	assert.Len(t, vulns, 1)

	vuln := vulns[0]
	assert.Equal(t, "VULN-123", vuln.ID)
	assert.Equal(t, "VULN-123/pkg:npm/test-package@1.0.0", vuln.BOMRef)
	assert.Equal(t, "Test vulnerability", vuln.Description)
	assert.Equal(t, "Upgrade to version 1.2.0 or later", vuln.Recommendation)

	ratings := utils.SafelyGetValue(vuln.Ratings)
	assert.Len(t, ratings, 1)
	assert.Equal(t, cdx.SeverityHigh, ratings[0].Severity)
	assert.Equal(t, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", ratings[0].Vector)
	assert.Equal(t, cdx.ScoringMethodCVSSv3, ratings[0].Method)
	assert.Equal(t, utils.PtrTo(7.5), ratings[0].Score)

	affects := utils.SafelyGetValue(vuln.Affects)
	assert.Len(t, affects, 1)
	assert.Equal(t, "pkg:npm/test-package@1.0.0", affects[0].Ref)
}

func TestCycloneDxReporterMalware(t *testing.T) {
	cdxPath := path.Join(t.TempDir(), "cdx.json")
	reporter, err := NewCycloneDXReporter(CycloneDXReporterConfig{
		Tool: cdxTestToolMetaData,
		Path: cdxPath,
	})
	assert.NoError(t, err)
	assert.NotNil(t, reporter)

	manifest := &models.PackageManifest{
		Path: "test/package.json",
		Source: models.PackageManifestSource{
			Type:        models.ManifestSourceLocal,
			Path:        "test/package.json",
			DisplayPath: "test/package.json",
		},
	}
	manifest.Packages = []*models.Package{
		{
			Manifest:       manifest,
			PackageDetails: models.NewPackageDetail("npm", "test-package", "1.0.0"),
			MalwareAnalysis: &models.MalwareAnalysisResult{
				AnalysisId:   "01JMZZA797H9A1EE73DJ19PNXM",
				IsMalware:    true,
				IsSuspicious: false,
				Report: &malysisv1.Report{
					Inference: &malysisv1.Report_Inference{
						IsMalware: true,
						Summary:   "Malware detected by malysis",
					},
				},
			},
		},
	}

	reporter.AddManifest(manifest)
	err = reporter.Finish()
	assert.NoError(t, err)

	generatedBom, err := decodeCdxSbom(cdxPath)
	assert.NoError(t, err)
	assert.NotNil(t, generatedBom)

	vulns := utils.SafelyGetValue(generatedBom.Vulnerabilities)
	assert.NotNil(t, vulns)
	assert.Len(t, vulns, 1)

	vuln := vulns[0]
	assert.Equal(t, "SD-MAL-01JMZZA797H9A1EE73DJ19PNXM", vuln.ID)
	assert.Equal(t, "SD-MAL-01JMZZA797H9A1EE73DJ19PNXM/pkg:npm/test-package@1.0.0", vuln.BOMRef)
	assert.Equal(t, "Malware detected by malysis", vuln.Description)
	assert.Equal(t, "", vuln.Recommendation)

	ratings := utils.SafelyGetValue(vuln.Ratings)
	assert.Len(t, ratings, 0)

	affects := utils.SafelyGetValue(vuln.Affects)
	assert.Len(t, affects, 1)
	assert.Equal(t, "pkg:npm/test-package@1.0.0", affects[0].Ref)

	assert.Equal(t, cdxTestToolMetaData.Name, vuln.Source.Name)
	assert.Equal(t, cdxTestToolMetaData.InformationURI, vuln.Source.URL)

	assert.Len(t, utils.SafelyGetValue(vuln.Credits.Organizations), 1)
	toolOrg := utils.SafelyGetValue(vuln.Credits.Organizations)[0]
	assert.Equal(t, cdxTestToolMetaData.VendorName, toolOrg.BOMRef)
	assert.Equal(t, cdxTestToolMetaData.VendorName, toolOrg.Name)
	assert.Equal(t, []string{cdxTestToolMetaData.VendorInformationURI}, utils.SafelyGetValue(toolOrg.URL))
}
